package maestro.device

import dadb.Dadb
import dadb.adbserver.AdbServer
import maestro.device.util.AndroidEnvUtils
import maestro.device.util.AvdDevice
import maestro.device.util.PrintUtils
import maestro.drivers.AndroidDriver
import maestro.utils.LocaleUtils
import maestro.utils.MaestroTimer
import okio.buffer
import okio.source
import org.slf4j.LoggerFactory
import util.DeviceCtlResponse
import java.io.File
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

object DeviceService {
    private val logger = LoggerFactory.getLogger(DeviceService::class.java)

    fun startDevice(
        device: Device.AvailableForLaunch,
        driverHostPort: Int?,
        connectedDevices: Set<String> = setOf()
    ): Device.Connected {
        when (device.platform) {
            Platform.IOS -> {
                try {
                    util.LocalSimulatorUtils.bootSimulator(device.modelId)
                    if (device.language != null && device.country != null) {
                        PrintUtils.message("Setting the device locale to ${device.language}_${device.country}...")
                        util.LocalSimulatorUtils.setDeviceLanguage(device.modelId, device.language)
                        LocaleUtils.findIOSLocale(device.language, device.country)?.let {
                            util.LocalSimulatorUtils.setDeviceLocale(device.modelId, it)
                        }
                        util.LocalSimulatorUtils.reboot(device.modelId)
                    }
                    util.LocalSimulatorUtils.launchSimulator(device.modelId)
                    util.LocalSimulatorUtils.awaitLaunch(device.modelId)
                } catch (e: util.LocalSimulatorUtils.SimctlError) {
                    logger.error("Failed to launch simulator", e)
                    throw DeviceError(e.message)

                }

                return Device.Connected(
                    instanceId = device.modelId,
                    description = device.description,
                    platform = device.platform,
                    deviceType = device.deviceType,
                )
            }

            Platform.ANDROID -> {
                val emulatorBinary = requireEmulatorBinary()

                ProcessBuilder(
                    emulatorBinary.absolutePath,
                    "-avd",
                    device.modelId,
                    "-netdelay",
                    "none",
                    "-netspeed",
                    "full"
                ).start().waitFor(10,TimeUnit.SECONDS)

                val dadb = MaestroTimer.withTimeout(60000) {
                    try {
                        Dadb.list().lastOrNull{ dadb ->
                            !connectedDevices.contains(dadb.toString())
                        }
                    } catch (ignored: Exception) {
                        Thread.sleep(100)
                        null
                    }
                } ?: throw DeviceError("Unable to start device: ${device.modelId}")

                PrintUtils.message("Waiting for emulator ( ${device.modelId} ) to boot...")
                while (!bootComplete(dadb)) {
                    Thread.sleep(1000)
                }

                if (device.language != null && device.country != null) {
                    PrintUtils.message("Setting the device locale to ${device.language}_${device.country}...")
                    val driver = AndroidDriver(dadb, driverHostPort)
                    driver.installMaestroDriverApp()
                    val result = driver.setDeviceLocale(
                        country = device.country,
                        language = device.language
                    )

                    when (result) {
                        SET_LOCALE_RESULT_SUCCESS -> PrintUtils.message("[Done] Setting the device locale to ${device.language}_${device.country}")
                        SET_LOCALE_RESULT_LOCALE_NOT_VALID -> throw IllegalStateException("Failed to set locale ${device.language}_${device.country}, the locale is not valid for a chosen device")
                        SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED -> throw IllegalStateException("Failed to set locale ${device.language}_${device.country}, exception during updating configuration occurred")
                        else -> throw IllegalStateException("Failed to set locale ${device.language}_${device.country}, unknown exception happened")
                    }
                    driver.uninstallMaestroDriverApp()
                }

                return Device.Connected(
                    instanceId = dadb.toString(),
                    description = device.description,
                    platform = device.platform,
                    deviceType = device.deviceType,
                )
            }

            Platform.WEB -> {
                return Device.Connected(
                    instanceId = "",
                    description = "Chromium Web Browser",
                    platform = device.platform,
                    deviceType = device.deviceType,
                )
            }
        }
    }

    fun listConnectedDevices(
        includeWeb: Boolean = false,
        host: String? = null,
        port: Int? = null,
    ): List<Device.Connected> {
        return listDevices(includeWeb = includeWeb, host, port)
            .filterIsInstance<Device.Connected>()
    }

    fun <T : Device> List<T>.withPlatform(platform: Platform?) =
        filter { platform == null || it.platform == platform }

    fun listAvailableForLaunchDevices(includeWeb: Boolean = false): List<Device.AvailableForLaunch> {
        return listDevices(includeWeb = includeWeb)
            .filterIsInstance<Device.AvailableForLaunch>()
    }

     fun listDevices(includeWeb: Boolean, host: String? = null, port: Int? = null): List<Device> {
        return listAndroidDevices(host, port) +
                listIOSDevices() +
                if (includeWeb) {
                    listWebDevices()
                } else {
                    listOf()
                }
    }

    private fun listWebDevices(): List<Device> {
        return listOf(
            Device.Connected(
                platform = Platform.WEB,
                description = "Chromium Web Browser",
                instanceId = "chromium",
                deviceType = Device.DeviceType.BROWSER
            ),
            Device.AvailableForLaunch(
                modelId = "chromium",
                language = null,
                country = null,
                description = "Chromium Web Browser",
                platform = Platform.WEB,
                deviceType = Device.DeviceType.BROWSER
            )
        )
    }

    private fun listAndroidDevices(host: String? = null, port: Int? = null): List<Device> {
        val host = host ?: "localhost"
        if (port != null) {
            val dadb = Dadb.create(host, port)
            return listOf(
                Device.Connected(
                    instanceId = dadb.toString(),
                    description = dadb.toString(),
                    platform = Platform.ANDROID,
                    deviceType = Device.DeviceType.EMULATOR
                )
            )
        }
        val connected = runCatching {
            Dadb.list(host = host).map { dadb ->
                val avdName = runCatching {
                    dadb.shell("getprop ro.kernel.qemu").output.trim().let { qemuProp ->
                        if (qemuProp == "1") {
                            val avdNameResult = ProcessBuilder("adb", "-s", dadb.toString(), "emu", "avd", "name")
                                .redirectErrorStream(true)
                                .start()
                                .apply { waitFor(5, TimeUnit.SECONDS) }
                                .inputStream.bufferedReader().readLine()?.trim() ?: ""

                            if (avdNameResult.isNotBlank() && !avdNameResult.contains("unknown AVD")) {
                                avdNameResult
                            } else null
                        } else null
                    }
                }.getOrNull()

                val instanceId = dadb.toString()
                val deviceType = when  {
                    instanceId.startsWith("emulator") -> Device.DeviceType.EMULATOR
                    else -> Device.DeviceType.REAL
                }
                Device.Connected(
                    instanceId = instanceId,
                    description = avdName ?: dadb.toString(),
                    platform = Platform.ANDROID,
                    deviceType = deviceType
                )
            }
        }.getOrNull() ?: emptyList()

        // Note that there is a possibility that AVD is actually already connected and is present in
        // connectedDevices.
        val avds = try {
            val emulatorBinary = requireEmulatorBinary()
            ProcessBuilder(emulatorBinary.absolutePath, "-list-avds")
                .start()
                .inputStream
                .bufferedReader()
                .useLines { lines ->
                    lines
                        .map {
                            Device.AvailableForLaunch(
                                modelId = it,
                                description = it,
                                platform = Platform.ANDROID,
                                language = null,
                                country = null,
                                deviceType = Device.DeviceType.EMULATOR
                            )
                        }
                        .toList()
                }
        } catch (ignored: Exception) {
            emptyList()
        }

        return connected + avds
    }

    private fun listIOSDevices(): List<Device> {
        val simctlList = try {
            util.LocalSimulatorUtils.list()
        } catch (ignored: Exception) {
            return emptyList()
        }

        val runtimeNameByIdentifier = simctlList
            .runtimes
            .associate { it.identifier to it.name }

        return simctlList
            .devices
            .flatMap { runtime ->
                runtime.value
                    .filter { it.isAvailable }
                    .map { device(runtimeNameByIdentifier, runtime, it) }
            } + listIOSConnectedDevices()
    }

    private fun listIOSConnectedDevices(): List<Device.Connected> {
        val connectedIphoneList = util.LocalIOSDevice().listDeviceViaDeviceCtl()

        return connectedIphoneList.mapNotNull { device ->
            val udid = device.hardwareProperties?.udid
            if (device.connectionProperties.tunnelState != DeviceCtlResponse.ConnectionProperties.CONNECTED || udid == null) {
                return@mapNotNull null
            }

            val description = listOfNotNull(
                device.deviceProperties?.name,
                device.deviceProperties?.osVersionNumber,
                device.identifier
            ).joinToString(" - ")

            Device.Connected(
                instanceId = udid,
                description = description,
                platform = Platform.IOS,
                deviceType = Device.DeviceType.REAL
            )
        }
    }

    private fun device(
        runtimeNameByIdentifier: Map<String, String>,
        runtime: Map.Entry<String, List<util.SimctlList.Device>>,
        device: util.SimctlList.Device,
    ): Device {
        val runtimeName = runtimeNameByIdentifier[runtime.key] ?: "Unknown runtime"
        val description = "${device.name} - $runtimeName - ${device.udid}"

        return if (device.state == "Booted") {
            Device.Connected(
                instanceId = device.udid,
                description = description,
                platform = Platform.IOS,
                deviceType = Device.DeviceType.SIMULATOR
            )
        } else {
            Device.AvailableForLaunch(
                modelId = device.udid,
                description = description,
                platform = Platform.IOS,
                language = null,
                country = null,
                deviceType =  Device.DeviceType.SIMULATOR
            )
        }
    }

    /**
     * @return true if ios simulator or android emulator is currently connected
     */
    fun isDeviceConnected(deviceName: String, platform: Platform): Device.Connected? {
        return when (platform) {
            Platform.IOS -> listIOSDevices()
                .filterIsInstance<Device.Connected>()
                .find { it.description.contains(deviceName, ignoreCase = true) }

            else -> runCatching {
                (Dadb.list() + AdbServer.listDadbs(adbServerPort = 5038))
                    .mapNotNull { dadb -> runCatching { dadb.shell("getprop ro.kernel.qemu.avd_name").output }.getOrNull() }
                    .map { output ->
                        Device.Connected(
                            instanceId = output,
                            description = output,
                            platform = Platform.ANDROID,
                            deviceType = Device.DeviceType.EMULATOR
                        )
                    }
                    .find { connectedDevice -> connectedDevice.description.contains(deviceName, ignoreCase = true) }
            }.getOrNull()
        }
    }

    /**
     * @return true if ios simulator or android emulator is available to launch
     */
    fun isDeviceAvailableToLaunch(deviceName: String, platform: Platform): Device.AvailableForLaunch? {
        return if (platform == Platform.IOS) {
            listIOSDevices()
                .filterIsInstance<Device.AvailableForLaunch>()
                .find { it.description.contains(deviceName, ignoreCase = true) }
        } else {
            listAndroidDevices()
                .filterIsInstance<Device.AvailableForLaunch>()
                .find { it.description.contains(deviceName, ignoreCase = true) }
        }
    }

    /**
     * Creates an iOS simulator
     *
     * @param deviceName Any name
     * @param device Simulator type as specified by Apple i.e. iPhone-11
     * @param os OS runtime name as specified by Apple i.e. iOS-16-2
     */
    fun createIosDevice(deviceName: String, device: String, os: String): UUID {
        val command = listOf(
            "xcrun",
            "simctl",
            "create",
            deviceName,
            "com.apple.CoreSimulator.SimDeviceType.$device",
            "com.apple.CoreSimulator.SimRuntime.$os"
        )

        val process = ProcessBuilder(*command.toTypedArray()).start()
        if (!process.waitFor(5, TimeUnit.MINUTES)) {
            throw TimeoutException()
        }

        if (process.exitValue() != 0) {
            val processOutput = process.errorStream
                .source()
                .buffer()
                .readUtf8()

            throw IllegalStateException(processOutput)
        } else {
            val output = String(process.inputStream.readBytes()).trim()
            return try {
                UUID.fromString(output)
            } catch (ignore: IllegalArgumentException) {
                throw IllegalStateException("Unable to create device. No UUID was generated")
            }
        }
    }

    /**
     * Creates an Android emulator
     *
     * @param deviceName Any device name
     * @param device Device type as specified by the Android SDK i.e. "pixel_6"
     * @param systemImage Full system package i.e "system-images;android-28;google_apis;x86_64"
     * @param tag google apis or playstore tag i.e. google_apis or google_apis_playstore
     * @param abi x86_64, x86, arm64 etc..
     */
    fun createAndroidDevice(
        deviceName: String,
        device: String,
        systemImage: String,
        tag: String,
        abi: String,
        force: Boolean = false,
        shardIndex: Int? = null,
    ): String {
        val avd = requireAvdManagerBinary()
        val name = "${deviceName}${"_${(shardIndex ?: 0) + 1}"}"
        val command = mutableListOf(
            avd.absolutePath,
            "create", "avd",
            "--name", name,
            "--package", systemImage,
            "--tag", tag,
            "--abi", abi,
            "--device", device,
        )

        if (force) command.add("--force")

        val process = ProcessBuilder(*command.toTypedArray()).start()

        if (!process.waitFor(5, TimeUnit.MINUTES)) {
            throw TimeoutException()
        }

        if (process.exitValue() != 0) {
            val processOutput = process.errorStream
                .source()
                .buffer()
                .readUtf8()

            throw IllegalStateException("Failed to start android emulator: $processOutput")
        }

        return name
    }

    fun getAvailablePixelDevices(): List<AvdDevice> {
        val avd = requireAvdManagerBinary()
        val command = mutableListOf(
            avd.absolutePath,
            "list", "device"
        )

        val process = ProcessBuilder(*command.toTypedArray()).start()

        if (!process.waitFor(1, TimeUnit.MINUTES)) {
            throw TimeoutException()
        }

        if (process.exitValue() != 0) {
            val processOutput = process.errorStream
                .source()
                .buffer()
                .readUtf8()

            throw IllegalStateException("Failed to list avd devices emulator: $processOutput")
        }

        return runCatching {
            AndroidEnvUtils.parsePixelDevices(String(process.inputStream.readBytes()).trim())
        }.getOrNull() ?: emptyList()
    }

    /**
     * @return true is Android system image is already installed
     */
    fun isAndroidSystemImageInstalled(image: String): Boolean {
        val command = listOf(
            requireSdkManagerBinary().absolutePath,
            "--list_installed"
        )
        try {
            val process = ProcessBuilder(*command.toTypedArray()).start()
            if (!process.waitFor(1, TimeUnit.MINUTES)) {
                throw TimeoutException()
            }

            if (process.exitValue() == 0) {
                val output = String(process.inputStream.readBytes()).trim()

                return output.contains(image)
            }
        } catch (e: Exception) {
            logger.error("Unable to detect if SDK package is installed", e)
        }

        return false
    }

    /**
     * Uses the Android SDK manager to install android image
     */
    fun installAndroidSystemImage(image: String): Boolean {
        val command = listOf(
            requireSdkManagerBinary().absolutePath,
            image
        )
        try {
            val process = ProcessBuilder(*command.toTypedArray())
                .inheritIO()
                .start()
            if (!process.waitFor(120, TimeUnit.MINUTES)) {
                throw TimeoutException()
            }

            if (process.exitValue() == 0) {
                val output = String(process.inputStream.readBytes()).trim()

                return output.contains(image)
            }
        } catch (e: Exception) {
            logger.error("Unable to install if SDK package is installed", e)
        }

        return false
    }

    fun getAndroidSystemImageInstallCommand(pkg: String): String {
        return listOf(
            requireSdkManagerBinary().absolutePath,
            "\"$pkg\""
        ).joinToString(separator = " ")
    }

    fun deleteIosDevice(uuid: String): Boolean {
        val command = listOf(
            "xcrun",
            "simctl",
            "delete",
            uuid
        )

        val process = ProcessBuilder(*command.toTypedArray()).start()

        if (!process.waitFor(1, TimeUnit.MINUTES)) {
            throw TimeoutException()
        }

        return process.exitValue() == 0
    }

    fun killAndroidDevice(deviceId: String): Boolean {
        val command = listOf("adb", "-s", deviceId, "emu", "kill")

        try {
            val process = ProcessBuilder(*command.toTypedArray()).start()

            if (!process.waitFor(1, TimeUnit.MINUTES)) {
                throw TimeoutException("Android kill command timed out")
            }

            val success = process.exitValue() == 0
            if (success) {
                logger.info("Killed Android device: $deviceId")
            } else {
                logger.error("Failed to kill Android device: $deviceId")
            }

            return success
        } catch (e: Exception) {
            logger.error("Error killing Android device: $deviceId", e)
            return false
        }
    }

    fun killIOSDevice(deviceId: String): Boolean {
        val command = listOf("xcrun", "simctl", "shutdown", deviceId)

        try {
            val process = ProcessBuilder(*command.toTypedArray()).start()

            if (!process.waitFor(1, TimeUnit.MINUTES)) {
                throw TimeoutException("iOS kill command timed out")
            }

            val success = process.exitValue() == 0
            if (success) {
                logger.info("Killed iOS device: $deviceId")
            } else {
                logger.error("Failed to kill iOS device: $deviceId")
            }

            return success
        } catch (e: Exception) {
            logger.error("Error killing iOS device: $deviceId", e)
            return false
        }
    }

    private fun bootComplete(dadb: Dadb): Boolean {
        return try {
            val booted = dadb.shell("getprop sys.boot_completed").output.trim() == "1"
            val settingsAvailable = dadb.shell("settings list global").exitCode == 0
            val packageManagerAvailable = dadb.shell("pm get-max-users").exitCode == 0
            return settingsAvailable && packageManagerAvailable && booted
        } catch (e: IllegalStateException) {
            false
        }
    }

    private fun requireEmulatorBinary(): File = AndroidEnvUtils.requireEmulatorBinary()

    private fun requireAvdManagerBinary(): File = AndroidEnvUtils.requireCommandLineTools("avdmanager")

    private fun requireSdkManagerBinary(): File = AndroidEnvUtils.requireCommandLineTools("sdkmanager")

    private const val SET_LOCALE_RESULT_SUCCESS = 0
    private const val SET_LOCALE_RESULT_LOCALE_NOT_VALID = 1
    private const val SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED = 2
}
